package searcher;

import java.util.regex.Pattern;

import de.schlichtherle.io.File;

/**
 * The SearchCriteria class is the brains behind the Searcher program. It contains all the
 * Searcher-specific logic for matching file paths and contents. It uses regular expressions as
 * defined and documented in {@link java.util.regex.Pattern}
 */
public class SearchCriteria
{
	/**
	 * Denotes that access is required to some property (read, write, hidden, etc.) to satisfy a
	 * SearchCriteria
	 */
	public static final int REQUIRED = 1;

	/**
	 * Denotes that access is allowed to some property (read, write, hidden, etc.) to satisfy a
	 * SearchCriteria
	 */
	public static final int ALLOWED = 0;

	/**
	 * Denotes that access is disallowed to some property (read, write, hidden, etc.) to satisfy a
	 * SearchCriteria
	 */
	public static final int DISALLOWED = -1;

	private Pattern thePathPattern;

	private Pattern theTextPattern;

	private String thePathText;

	private String theTextText;

	private int theTextSearchBufferSize;

	private boolean textSearchMultiple;

	private boolean textSearchByWord;

	private int theWriteAccess = ALLOWED;

	private int theReadAccess = ALLOWED;

	private int theHiddenAccess = ALLOWED;

	private int theDirectoryAccess = ALLOWED;

	private long theMinModified = 0;

	private long theMaxModified = System.currentTimeMillis() + 3600000;

	private long theMinSize = 0;

	private long theMaxSize = 8L * 1024 * 1024 * 1024; // 8 gigs

	private volatile boolean stop;

	private java.util.List<SearchListener> theListeners = new java.util.ArrayList<SearchListener>();

	/**
	 * Creates a SearchCriteria with a text pattern and a path pattern, either or both of which may
	 * be null
	 * 
	 * @param pathPattern The pattern to be used for matching the path of a file. If this is null,
	 *        all paths will be accepted.
	 * @param textPattern The pattern to be used for matching the contents of a file. If this is
	 *        null, all contents will be accepted and files will not even be opened.
	 */
	public SearchCriteria(Pattern pathPattern, Pattern textPattern)
	{
		setPathPattern(pathPattern);
		setTextPattern(textPattern);
		init();
	}

	/**
	 * Creates a SearchCriteria from Strings and case-insensitive indicators instead of pre-compiled
	 * patterns
	 * 
	 * @param pathPattern The content for the path pattern
	 * @param pathCI Whether to evaluate the path pattern case-insensitively
	 * @param textPattern The content for the text pattern
	 * @param textCI Whether to evaluate the text pattern case-insensitively
	 */
	public SearchCriteria(String pathPattern, boolean pathCI, String textPattern, boolean textCI)
	{
		setPathPattern(pathPattern, pathCI);
		setTextPattern(textPattern, textCI);
		init();
	}

	private void init()
	{
		theTextSearchBufferSize = 0x2000;
		textSearchMultiple = false;
		textSearchByWord = true;
	}

	/**
	 * @return The pattern used for matching the file path
	 */
	public Pattern getPathPattern()
	{
		return thePathPattern;
	}

	/**
	 * @return The path pattern string, as entered by a user
	 */
	public String getPathPatternString()
	{
		return thePathText;
	}

	/**
	 * @param p The pattern to use for matching the file path
	 */
	public void setPathPattern(Pattern p)
	{
		thePathPattern = p;
		thePathText = p == null ? null : p.pattern();
	}

	/**
	 * @param p The content of a pattern to use for matching the file path
	 * @param ci Whether <code>p</code> is to be evaluated case-insensitively
	 */
	public void setPathPattern(String p, boolean ci)
	{
		Pattern newPat;
		if(p == null)
			newPat = null;
		else
		{
			int pathFlags = 0;
			if(ci)
				pathFlags |= Pattern.CASE_INSENSITIVE;
			newPat = Pattern.compile(replacePath(p), pathFlags);
		}
		setPathPattern(newPat);
		thePathText = p;
	}

	/**
	 * @return The pattern used for matching the file text
	 */
	public Pattern getTextPattern()
	{
		return theTextPattern;
	}

	/**
	 * @return The text pattern string, as entered by a user
	 */
	public String getTextPatternString()
	{
		return theTextText;
	}

	/**
	 * @param p The pattern to use for matching the file text
	 */
	public void setTextPattern(Pattern p)
	{
		theTextPattern = p;
		theTextText = p == null ? null : p.pattern();
	}

	/**
	 * @param p The content of a pattern to use for matching the file text
	 * @param ci Whether <code>p</code> is to be evaluated case-insensitively
	 */
	public void setTextPattern(String p, boolean ci)
	{
		Pattern newPat;
		if(p == null)
			newPat = null;
		else
		{
			int pathFlags = 0;
			if(ci)
				pathFlags |= Pattern.CASE_INSENSITIVE;
			newPat = Pattern.compile(replaceText(p), pathFlags);
		}
		setTextPattern(newPat);
		theTextText = p;
	}

	/**
	 * Performs some operations on the path pattern to make it fit a typical file path
	 * 
	 * @param pattern The pattern to adjust
	 * @return The adjusted pattern
	 */
	public String replacePath(String pattern)
	{
		String ret = pattern;
		ret = ret.replaceAll("\\\\\\\\", "*SLASH_PLACE_HOLDER*");
		ret = ret.replaceAll("\\\\.", "*DOT_PLACE_HOLDER*");
		ret = ret.replaceAll("\\.\\*", "([^/\\])*");
		ret = ret.replaceAll("\\\\\\\\", "/");
		ret = ret.replaceAll("\\*DOT_PLACE_HOLDER\\*", "\\\\.");
		ret = ret.replaceAll("\\*SLASH_PLACE_HOLDER\\*", "\\\\\\\\");
		ret = ret.replaceAll("//", "(/.*)??/");
		ret = ".*/??" + ret;
		if(!ret.endsWith("/"))
			ret += "/?";
		return ret;
	}

	/**
	 * Performs some operations on the file pattern to make it fit a typical file
	 * 
	 * @param pattern The pattern to adjust
	 * @return The adjusted pattern
	 */
	public String replaceText(String pattern)
	{
		String ret = pattern;
		if(textSearchByWord)
			ret = "(\\A|[\\W])" + ret + "(\\r|\\W)";
		return ret;
	}

	/**
	 * Adds a listener to be notified when a path pattern, text pattern, or whole file are matched
	 * 
	 * @param sl The SearchListener to add
	 */
	public void addSearchListener(SearchListener sl)
	{
		theListeners.add(sl);
	}

	/**
	 * Removes a SearchListener from the list so that it is no longer notified of file matches
	 * 
	 * @param sl The SearchListener to remove
	 */
	public void removeSearchListener(SearchListener sl)
	{
		theListeners.remove(sl);
	}

	/**
	 * @return The size of the buffer used to speed access to the contents of a file when comparing
	 *         them against the text pattern
	 */
	public int getTextSearchBufferSize()
	{
		return theTextSearchBufferSize;
	}

	/**
	 * @param aSize The size of the buffer to use to speed access to the contents of a file when
	 *        comparing them against the text pattern
	 */
	public void setTextSearchBufferSize(int aSize)
	{
		theTextSearchBufferSize = aSize;
	}

	/**
	 * @return Whether or not this SearchCriteria searches the contents of a file for every match or
	 *         just looks for one match
	 */
	public boolean getTextSearchMultiple()
	{
		return textSearchMultiple;
	}

	/**
	 * @param b Whether or not this SearchCriteria should search the contents of a file for every
	 *        match or just looks for one match
	 */
	public void setTextSearchMultiple(boolean b)
	{
		textSearchMultiple = b;
	}

	/**
	 * @return Whether this SearchCriteria looks for whole words (white space on both sides of a
	 *         match)
	 */
	public boolean isTextSearchByWord()
	{
		return textSearchByWord;
	}

	/**
	 * @param b Whether this SearchCriteria is to look for whole words (white space on both sides of
	 *        a match)
	 */
	public void setTextSearchByWord(boolean b)
	{
		textSearchByWord = b;
		if(theTextPattern != null)
			setTextPattern(theTextText, (theTextPattern.flags() | Pattern.CASE_INSENSITIVE) != 0);
	}

	/**
	 * @return The rule used to determine if a file's read access passes this SearchCriteria
	 * @see #REQUIRED
	 * @see #ALLOWED
	 * @see #DISALLOWED
	 */
	public int getReadAccess()
	{
		return theReadAccess;
	}

	/**
	 * @param ra The rule to be used to determine if a file's read access passes this SearchCriteria
	 * @see #REQUIRED
	 * @see #ALLOWED
	 * @see #DISALLOWED
	 */
	public void setReadAccess(int ra)
	{
		if(ra != REQUIRED && ra != ALLOWED && ra != DISALLOWED)
			throw new IllegalArgumentException("Illegal read access value");
		theReadAccess = ra;
	}

	/**
	 * @return The rule used to determine if a file's write access passes this SearchCriteria
	 * @see #REQUIRED
	 * @see #ALLOWED
	 * @see #DISALLOWED
	 */
	public int getWriteAccess()
	{
		return theWriteAccess;
	}

	/**
	 * @param wa The rule to be used to determine if a file's write access passes this
	 *        SearchCriteria
	 * @see #REQUIRED
	 * @see #ALLOWED
	 * @see #DISALLOWED
	 */
	public void setWriteAccess(int wa)
	{
		if(wa != REQUIRED && wa != ALLOWED && wa != DISALLOWED)
			throw new IllegalArgumentException("Illegal write access value");
		theWriteAccess = wa;
	}

	/**
	 * @return The rule used to determine if a file's hidden attribute passes this SearchCriteria
	 * @see #REQUIRED
	 * @see #ALLOWED
	 * @see #DISALLOWED
	 */
	public int getHiddenAccess()
	{
		return theHiddenAccess;
	}

	/**
	 * @param ha The rule to be used to determine if a file's hidden attribute passes this
	 *        SearchCriteria
	 * @see #REQUIRED
	 * @see #ALLOWED
	 * @see #DISALLOWED
	 */
	public void setHiddenAccess(int ha)
	{
		if(ha != REQUIRED && ha != ALLOWED && ha != DISALLOWED)
			throw new IllegalArgumentException("Illegal hidden access value");
		theHiddenAccess = ha;
	}

	/**
	 * @return The rule used to determine if a file's directory attribute passes this SearchCriteria
	 * @see #REQUIRED
	 * @see #ALLOWED
	 * @see #DISALLOWED
	 */
	public int getDirectoryAccess()
	{
		return theDirectoryAccess;
	}

	/**
	 * @param da The rule to be used to determine if a file's directory attribute passes this
	 *        SearchCriteria
	 * @see #REQUIRED
	 * @see #ALLOWED
	 * @see #DISALLOWED
	 */
	public void setDirectoryAccess(int da)
	{
		if(da != REQUIRED && da != ALLOWED && da != DISALLOWED)
			throw new IllegalArgumentException("Illegal directory access value");
		theDirectoryAccess = da;
	}

	/**
	 * @return The minimum modification time a file can have to pass this SearchCriteria
	 */
	public long getMinModTime()
	{
		return theMinModified;
	}

	/**
	 * @param mmt The minimum modification time a file should have to pass this SearchCriteria
	 */
	public void setMinModTime(long mmt)
	{
		theMinModified = mmt;
	}

	/**
	 * @return The maximum modification time a file can have to pass this SearchCriteria
	 */
	public long getMaxModTime()
	{
		return theMaxModified;
	}

	/**
	 * @param mmt The maximum modification time a file should have to pass this SearchCriteria
	 */
	public void setMaxModTime(long mmt)
	{
		theMaxModified = mmt;
	}

	/**
	 * @return The minimum size a file can have to pass this SearchCriteria
	 */
	public long getMinSize()
	{
		return theMinSize;
	}

	/**
	 * @param ms The minimum size a file should have to pass this SearchCriteria
	 */
	public void setMinSize(long ms)
	{
		theMinSize = ms;
	}

	/**
	 * @return The maximum size a file can have to pass this SearchCriteria
	 */
	public long getMaxSize()
	{
		return theMaxSize;
	}

	/**
	 * @param ms The maximum size a file should have to pass this SearchCriteria
	 */
	public void setMaxSize(long ms)
	{
		theMaxSize = ms;
	}

	/**
	 * Stops a comparison in progress
	 */
	public void stop()
	{
		stop = true;
	}

	/**
	 * Undoes a stop operation if it has not taken effect--sets the stop state back to normal
	 */
	public void unstop()
	{
		stop = false;
	}

	/**
	 * Performs the comparison operation on a file
	 * 
	 * @param aFile The file to test with this SearchCriteria
	 * @return The FileMatch result, or null if <code>aFile</code> does not match this
	 *         SearchCriteria
	 */
	public FileMatch matches(File aFile)
	{
		for(int i = 0; i < theListeners.size(); i++)
			theListeners.get(i).fileSearched(this, aFile);
		TextMatch [] textMatches;
		if(matchesAccess(aFile) && matchesModTime(aFile) && matchesSize(aFile)
			&& matchesPath(aFile))
		{
			for(int i = 0; i < theListeners.size(); i++)
				theListeners.get(i).filePathMatch(this, aFile);
		}
		else
			return null;

		try
		{
			textMatches = matchesText(aFile);
			if(textMatches == null)
				return null;
		} catch(java.io.IOException e)
		{
			return null;
		}
		FileMatch ret = new FileMatch(aFile.getPath(), textMatches);
		for(int i = 0; i < theListeners.size(); i++)
			theListeners.get(i).fileMatch(this, ret);
		return ret;
	}

	/**
	 * Determines if a file's access attributes match this SearchCriteria
	 * 
	 * @param aFile The file to test
	 * @return Whether <code>aFile</code>'s access attributes match this SearchCriteria
	 */
	public boolean matchesAccess(File aFile)
	{
		if(theDirectoryAccess != ALLOWED)
		{
			if(theDirectoryAccess == REQUIRED != aFile.isDirectory())
				return false;
		}
		if(theReadAccess != ALLOWED)
		{
			if(theReadAccess == REQUIRED != aFile.canRead())
				return false;
		}
		if(theWriteAccess != ALLOWED)
		{
			if(theWriteAccess == REQUIRED != aFile.canWrite())
				return false;
		}
		if(theHiddenAccess != ALLOWED)
		{
			if(theHiddenAccess == REQUIRED != aFile.isHidden())
				return false;
		}
		return true;
	}

	/**
	 * Determines if a file's modification time matches this SearchCriteria
	 * 
	 * @param aFile The file to test
	 * @return Whether <code>aFile</code>'s modification time matches this SearchCriteria
	 */
	public boolean matchesModTime(File aFile)
	{
		long modTime = aFile.lastModified();
		return theMinModified <= modTime && modTime <= theMaxModified;
	}

	/**
	 * Determines if a file's size matches this SearchCriteria. Directories always match
	 * 
	 * @param aFile The file to test
	 * @return Whether <code>aFile</code>'s size matches this SearchCriteria
	 */
	public boolean matchesSize(File aFile)
	{
		if(aFile.isDirectory())
			return true;
		long size = aFile.length();
		return theMinSize <= size && size <= theMaxSize;
	}

	/**
	 * @param aFile The file to test
	 * @return Whether <code>aFile</code>'s path matches this SearchCriteria's path pattern
	 */
	public boolean matchesPath(File aFile)
	{
		return thePathPattern == null
			|| thePathPattern.matcher(aFile.getPath().replaceAll("\\\\", "/")).matches();
	}

	/**
	 * @param aFile The file to test
	 * @return null if the contens of <code>aFile</code> do not match this SearchCriteria's text
	 *         pattern, a set of TextMatch objects for each text match if textSearchMultiple is
	 *         true, or a 0-length array if textSearchMultiple is false or this SearchCriteria's
	 *         text pattern is null.
	 * @throws java.io.IOException If an I/O error occurs while reading the contents of
	 *         <code>aFile</code>
	 */
	public TextMatch [] matchesText(File aFile) throws java.io.IOException
	{
		if(theTextPattern == null)
			return new TextMatch [0];
		if(aFile.isDirectory())
			return null;
		java.io.InputStream is = null;
		java.util.ArrayList<TextMatch> ret = new java.util.ArrayList<TextMatch>();
		try
		{
			is = new de.schlichtherle.io.FileInputStream(aFile);
			java.io.Reader r = new java.io.InputStreamReader(is);
			char [] cbuf = new char [theTextSearchBufferSize];
			int amtRead = r.read(cbuf);
			int strLen = amtRead;
			int charIdx;
			FilePosition pos = new FilePosition();
			FilePosition tempPos = new FilePosition();
			java.util.Set<FilePosition> found = new java.util.HashSet<FilePosition>();
			while(amtRead > 0)
			{
				if(stop)
					break;
				// do matching
				String cStr = new String(cbuf, 0, strLen);
				java.util.regex.Matcher m = theTextPattern.matcher(cStr);
				charIdx = 0;
				tempPos.assign(pos);
				while(m.find())
				{
					if(stop)
						break;
					if(!textSearchMultiple)
						return new TextMatch [0];
					int start = m.start();
					if(String.valueOf(cbuf[start]).matches("\\W"))
						start++;
					advanceCursorPosition(tempPos, cbuf, charIdx, start);
					charIdx = start;
					if(found.contains(tempPos))
						continue;
					found.add(tempPos);
					TextMatch tm = tempPos.textMatch();
					for(int i = 0; i < theListeners.size(); i++)
						theListeners.get(i).fileTextMatch(this, aFile, tm);
					ret.add(tm);
				}

				// rebuffer
				advanceCursorPosition(pos, cbuf, 0, cbuf.length / 2);
				System.arraycopy(cbuf, cbuf.length / 2, cbuf, 0, cbuf.length / 2);
				amtRead = r.read(cbuf, cbuf.length / 2, (cbuf.length + 1) / 2);
				strLen = cbuf.length / 2 + amtRead;
			}
		} finally
		{
			if(is != null)
				is.close();
		}
		if(ret.size() == 0)
			return null;
		else
			return ret.toArray(new TextMatch [ret.size()]);
	}

	private static void advanceCursorPosition(FilePosition pos, char [] cbuf, int start, int end)
	{
		for(int i = start; i < end; i++)
		{
			if(isLineBreak(cbuf, i))
			{
				i += lineBreakLength(cbuf, i) - 1;
				pos.newLine();
			}
			else
				pos.charNo++;
		}
	}

	private static boolean isLineBreak(char [] cbuf, int pos)
	{
		return cbuf[pos] == '\n' || cbuf[pos] == '\r';
	}

	private static int lineBreakLength(char [] cbuf, int pos)
	{
		if(pos + 1 < cbuf.length)
		{
			if(cbuf[pos] == '\n' && cbuf[pos + 1] == '\r')
				return 2;
			else if(cbuf[pos] == '\r' && cbuf[pos + 1] == '\n')
				return 2;
		}
		return 1;
	}

	public String toString()
	{
		String pp = thePathPattern == null ? "null" : thePathPattern.pattern();
		String tp = theTextPattern == null ? "null" : theTextPattern.pattern();
		return "Search Path: " + pp + ", Text: " + tp;
	}

	/**
	 * A utility class to keep track of the line and character position in a file.
	 * 
	 * @version $Id: SearchCriteria.java,v 1.11 2006/06/02 17:19:30 abutler Exp $
	 */
	private static final class FilePosition
	{
		/**
		 * The line number of this position in a file
		 */
		public int lineNo;

		/**
		 * The character number of this position in a file
		 */
		public int charNo;

		/**
		 * Creates a FilePosition
		 */
		public FilePosition()
		{
			reset();
		}

		/**
		 * Sets this FilePosition back to line 1, char 1
		 */
		public void reset()
		{
			lineNo = 1;
			charNo = 1;
		}

		/**
		 * Called when a newline is encountered.
		 */
		public void newLine()
		{
			lineNo++;
			charNo = 1;
		}

		/**
		 * @return A TextMatch corresponding to this FilePosition
		 */
		public TextMatch textMatch()
		{
			return new TextMatch(lineNo, charNo);
		}

		/**
		 * Assigns the value of <code>fp</code> to this FilePosition
		 * 
		 * @param fp The FilePosition with the value to assign to this FilePosition
		 */
		public void assign(FilePosition fp)
		{
			lineNo = fp.lineNo;
			charNo = fp.charNo;
		}

		public boolean equals(Object o)
		{
			return o instanceof FilePosition && ((FilePosition) o).lineNo == lineNo
				&& ((FilePosition) o).charNo == charNo;
		}

		public int hashCode()
		{
			return 37 + lineNo * 17 - charNo * 29;
		}

		public String toString()
		{
			return "line " + lineNo + ", char " + charNo;
		}
	}
}
